散布図#

散布図Scatter ) とは、主に二つの量的変数に対して、一つ一つのデータを マーカーの位置 で表す可視化手法です。 マーカーの で質的変数を表現することもあります。 また、マーカーの 大きさ で三つ目の量的変数を表現することもでき、これを特にバブルチャートと呼びます。

ゲームプラットフォームごとの合計パッケージ数と合計パブリッシャー数の関係を表現した散布図を例に説明します。 散布図では、一つ目の量的変数(上図「パッケージ数」)の数量を一つ目の 位置 スケール(上図「位置①」)で表し、 二つ目の量的変数(上図「パブリッシャー数」)の数量を直交する二つ目の 位置 スケール(上図「位置②」)で表現します。 マーカーの色や形によって質的変数(上図「メーカー名」)の水準(上図「ソニー[1]」)を表現することも可能です。

Plotlyでは、plotly.express.scatter()を用いて散布図を作成できます。

# plotly.expressモジュールをpxという名前でインポート
# 簡単にインタラクティブな図を作成するためのモジュール
import plotly.express as px

# px.scatter関数を使用して、散布図を作成
# 'df'データフレームの'col_x'カラムをx軸、'col_y'カラムをy軸に設定
# 作成した図は'fig'という変数に保存される
fig = px.scatter(df, x="col_x", y="col_y")

初期設定#

以降では、マンガ・アニメ・ゲームデータを可視化するための初期設定を行います。 なお、紙幅の都合のため、書籍版と一部構成が異なることにご注意ください。

Import#

必要なライブラリをImportします。

Hide code cell content
# warningsモジュールのインポート
import warnings

# データ解析や機械学習のライブラリ使用時の警告を非表示にする目的で警告を無視
# 本書の文脈では、可視化の学習に議論を集中させるために選択した
# ただし、学習以外の場面で、警告を無視する設定は推奨しない
warnings.filterwarnings("ignore")
Hide code cell content
# pathlibモジュールのインポート
# ファイルシステムのパスを扱う
from pathlib import Path

# numpy:数値計算ライブラリのインポート
# npという名前で参照可能
import numpy as np

# pandas:データ解析ライブラリのインポート
# pdという名前で参照可能
import pandas as pd

# plotly.expressのインポート
# インタラクティブなグラフ作成のライブラリ
# pxという名前で参照可能
import plotly.express as px

# plotly.graph_objectsのインポート
# より詳細なグラフ作成機能を利用可能
# goという名前で参照可能
import plotly.graph_objects as go

# plotly.graph_objectsからFigureクラスのインポート
# 型ヒントの利用を主目的とする
from plotly.graph_objects import Figure

なお、型ヒントについてはこちらを参照ください。

定数#

本Notebookで用いる定数を定義します。 なお、Pythonにおける定数の扱いについては、こちらを参照ください。

Hide code cell content
# マンガデータ保存ディレクトリのパス
DIR_CM = Path("../../data/cm/input")
# アニメデータ保存ディレクトリのパス
DIR_AN = Path("../../data/an/input")
# ゲームデータ保存ディレクトリのパス
DIR_GM = Path("../../data/gm/input")

# マンガデータの分析結果の出力先ディレクトリのパス
DIR_OUT_CM = DIR_CM.parent / "output" / Path.cwd().parts[-1] / "scatter"
# アニメデータの分析結果の出力先ディレクトリのパス
DIR_OUT_AN = DIR_AN.parent / "output" / Path.cwd().parts[-1] / "scatter"
# ゲームデータの分析結果の出力先ディレクトリのパス
DIR_OUT_GM = DIR_GM.parent / "output" / Path.cwd().parts[-1] / "scatter"
Hide code cell content
# 読み込み対象ファイル名の定義

# マンガ各話に関するファイル
FN_CE = "cm_ce.csv"

# マンガ作品と原作者の対応関係に関するファイル
FN_CC_CRT = "cm_cc_crt.csv"

# アニメ各話に関するファイル
FN_AE = "an_ae.csv"

# アニメ作品と声優の対応関係に関するファイル
FN_AC_ACT = "an_ac_act.csv"

# ゲームパッケージとプラットフォームの対応関係に関するファイル
FN_PKG_PF = "gm_pkg_pf.csv"
Hide code cell content
# 国内主要ゲームメーカーのプラットフォームとメーカー名の対応辞書
# キー: プラットフォーム名、値: メーカー名の略称
PF2MK = {
    "プレイステーション": "ソニー",
    "プレイステーション2": "ソニー",
    "プレイステーション・ポータブル": "ソニー",
    "プレイステーション3": "ソニー",
    "プレイステーションVita": "ソニー",
    "プレイステーション4": "ソニー",
    "ゲームアーカイブス": "ソニー",
    "SG-1000": "セガ",
    "SC-3000": "セガ",
    "SEGAマーク3": "セガ",
    "セガ・マスターシステム": "セガ",
    "メガドライブ": "セガ",
    "ゲームギア": "セガ",
    "セガサターン": "セガ",
    "ドリームキャスト": "セガ",
    "ファミリーコンピュータ": "任天堂",
    "ゲームボーイ": "任天堂",
    "スーパーファミコン": "任天堂",
    "NINTENDO64": "任天堂",
    "ゲームボーイアドバンス": "任天堂",
    "ニンテンドーゲームキューブ": "任天堂",
    "ニンテンドーDS": "任天堂",
    "ニンテンドー3DS": "任天堂",
    "Wii": "任天堂",
    "WiiU": "任天堂",
    "NintendoSwitch": "任天堂",
}
Hide code cell content
# 質的変数の描画用のカラースケールの定義

# Okabe and Ito (2008)基準のカラーパレット
# 色の識別性が高く、多様な色覚の人々にも見やすい色組み合わせ
# 参考URL: https://jfly.uni-koeln.de/color/#pallet
OKABE_ITO = [
    "#000000",  # 黒 (Black)
    "#E69F00",  # 橙 (Orange)
    "#56B4E9",  # 薄青 (Sky Blue)
    "#009E73",  # 青緑 (Bluish Green)
    "#F0E442",  # 黄色 (Yellow)
    "#0072B2",  # 青 (Blue)
    "#D55E00",  # 赤紫 (Vermilion)
    "#CC79A7",  # 紫 (Reddish Purple)
]
Hide code cell content
# plotlyの描画設定の定義

# plotlyのグラフ描画用レンダラーの定義
# Jupyter Notebook環境のグラフ表示に適切なものを選択
RENDERER = "plotly_mimetype+notebook"

関数#

以下では、本Notebookで用いる関数を定義します。

Hide code cell content
def show_fig(fig: Figure, watermark: str = None, position: str = None) -> None:
    """
    所定のレンダラーを用いてplotlyの図を表示する関数
    オプションで図に透かしを追加可能

    Parameters
    ----------
    fig : Figure
        表示対象のplotly図
    watermark : str, optional
        図に追加する透かしのテキスト
    position : str, optional
        透かしの位置 ('topleft', 'topright', 'bottomleft', 'bottomright')

    Returns
    -------
    None
    """

    # 図の周囲の余白を設定
    fig.update_layout(margin=dict(t=25, l=25, r=25, b=25))

    # 透かしの位置を辞書で定義
    # キーは位置を表す文字列、値はx, y座標のタプル
    position_dict = {
        "topleft": (0.05, 0.95),
        "topright": (0.95, 0.95),
        "bottomleft": (0.05, 0.05),
        "bottomright": (0.95, 0.05),
    }

    # 透かしを追加する場合の処理
    # 指定された位置が辞書に存在する場合のみ、透かしを追加
    if watermark and position in position_dict:
        # 指定された位置に対応する座標を取得
        x_pos, y_pos = position_dict[position]

        # 透かしを図に追加
        # 透かしはテキストで、指定された位置に表示される
        # フォントサイズは60、色は薄い灰色
        fig.add_annotation(
            text=watermark,
            xref="paper",
            yref="paper",
            x=x_pos,
            y=y_pos,
            showarrow=False,
            font=dict(size=60, color="lightgrey"),
            opacity=0.8,
        )

    # 所定のレンダラーを使用して図を表示
    fig.show(renderer=RENDERER)
Hide code cell content
def add_jitter(values: pd.Series, scale: float = 0.3, seed: int = None) -> pd.Series:
    """
    与えられた値にジッタリング(ランダムなノイズ)を適用する

    Parameters
    ----------
    values : pd.Series
        ジッタリングを適用する数値が含まれるPandasのSeries
    scale : float, optional
        ランダムノイズの大きさを調整するためのスケール因子、デフォルトは0.3
    seed : int, optional
        乱数生成のためのシード値、指定された場合再現可能なランダムノイズが生成される

    Returns
    -------
    pd.Series
        ジッタリングが適用された数値を含むPandasのSeries
    """

    # シード値が指定された場合は、乱数ジェネレータを初期化
    if seed is not None:
        np.random.seed(seed)

    # ランダムなノイズを生成して値に加える
    return values + np.random.randn(len(values)) * scale
Hide code cell content
def save_df_to_csv(df: pd.DataFrame, dir_save: Path, fn_save: str) -> None:
    """
    DataFrameをCSVファイルとして指定されたディレクトリに保存する関数

    Parameters
    ----------
    df : pd.DataFrame
        保存対象となるDataFrame
    dir_save : Path
        出力先ディレクトリのパス
    fn_save : str
        保存するCSVファイルの名前(拡張子は含めない)
    """
    # 出力先ディレクトリが存在しない場合は作成
    dir_save.mkdir(parents=True, exist_ok=True)

    # 出力先のパスを作成
    p_save = dir_save / f"{fn_save}.csv"

    # DataFrameをCSVファイルとして保存する
    df.to_csv(p_save, index=False, encoding="utf-8-sig")

    # 保存完了のメッセージを表示する
    print(f"DataFrame is saved as '{p_save}'.")

可視化例#

マンガデータ#

雑誌巻号に関する量的変数を例に、可視化手法を説明します。

Hide code cell content
# pandasのread_csv関数でCSVファイルの読み込み
df_ce = pd.read_csv(DIR_CM / FN_CE)
df_cc_crt = pd.read_csv(DIR_CM / FN_CC_CRT)
Hide code cell content
# 可視化のための集計
# 各マンガ作品(cc)に紐づく原作者(crt)の情報をマージ
df_cm = pd.merge(df_ce, df_cc_crt[["ccid", "crtid", "crtname"]], on="ccid", how="outer")

# 雑誌の各巻号(miname)ごとにデータを集計
df_cm = (
    df_cm.groupby(["miname"])[["ccid", "crtid", "page_end", "date", "price", "mcname"]]
    .agg(
        {
            "ccid": "nunique",  # 作品数:ユニークなccidの数
            "crtid": "nunique",  # 作者数:ユニークなcrtidの数
            "page_end": "max",  # 合計ページ数:page_endの最大値
            "date": "first",  # 発売日:dateの最初の値
            "price": "first",  # 価格:priceの最初の値
            "mcname": "first",  # 雑誌名:mcnameの最初の値
        }
    )
    .reset_index()
)

# カラム名をわかりやすく変更
df_cm = df_cm.rename(
    columns={
        "miname": "マンガ雑誌巻号名",
        "ccid": "マンガ作品数",
        "crtid": "マンガ作者数",
        "page_end": "合計ページ数",
        "date": "発売日",
        "price": "価格",
        "mcname": "マンガ雑誌名",
    }
)
Hide code cell content
# 可視化対象のDataFrameを確認
df_cm.head()
マンガ雑誌巻号名 マンガ作品数 マンガ作者数 合計ページ数 発売日 価格 マンガ雑誌名
0 週刊少年サンデー 1970年 表示号数32 12 14 284.0 1970-08-02 80.0 週刊少年サンデー
1 週刊少年サンデー 1970年 表示号数33 12 16 307.0 1970-08-09 90.0 週刊少年サンデー
2 週刊少年サンデー 1970年 表示号数34 13 17 314.0 1970-08-16 90.0 週刊少年サンデー
3 週刊少年サンデー 1970年 表示号数35 13 17 305.0 1970-08-23 90.0 週刊少年サンデー
4 週刊少年サンデー 1970年 表示号数36 13 17 305.0 1970-08-30 90.0 週刊少年サンデー
Hide code cell content
# 可視化対象DataFrameを保存
save_df_to_csv(df_cm, DIR_OUT_CM, "cm")
DataFrame is saved as '../../data/cm/output/08/scatter/cm.csv'.
Hide code cell source
# px.scatter関数を使用して散布図を作成
# x軸に作品数、y軸に作者数をプロット
# 各点には雑誌巻号名、発売日、雑誌名をホバー情報として表示
fig = px.scatter(
    df_cm,
    x="マンガ作品数",
    y="マンガ作者数",
    hover_name="マンガ雑誌巻号名",
    hover_data=["発売日", "マンガ雑誌名"],
)

# 散布図のマーカーのスタイルを更新
# サイズを10に設定し、線幅を1、透明度を0.5に設定
fig.update_traces(
    marker={
        "size": 10,
        "line_width": 1,
        "opacity": 0.5,
    }
)

# 散布図を表示
show_fig(fig)

上図は、マンガ雑誌巻号中の作品数と作者数の関係を表現した散布図です。 マーカーが右に位置するほど作品数が多く、上に位置するほど作者数が多いことを表します。

当然ですが、作品数が増えるほど作者数が増えていくことがわかりました。

一つのマンガ作品に対して必ず一人以上のマンガ作者が対応付けられているため、マーカーは直線 \(y=x\) より 上部 (作品数より作者数が多い) に位置すると考えるのが自然です。 そこで、上図の散布図に補助線\(y=x\)を追加してこの仮説を確かめてみましょう。

Hide code cell source
# 直線y=xの最小値と最大値を規定
# 最小値は0、最大値は散布図の最大値の1.1倍
x_line = [0, df_cm["マンガ作品数"].max() * 1.1]

# 2点を使ってy = xの直線を追加
fig.add_trace(go.Scatter(x=x_line, y=x_line, mode="lines", name="y=x"))

# 散布図を表示
show_fig(fig)

赤い実線は散布図の理解を助けるために導入した補助線\(y=x\)です。 回帰直線ではありません [2]のでご注意ください。

想定通り、ほとんどのマンガ雑誌巻号が直線\(y=x\)より上部に位置することがわかりました。 しかし、一部のマンガ雑誌巻号は直線\(y=x\)より大きく下、つまり作者数より作品数が遥かに多いことがわかりました。 どのようなケースがこれに該当するのでしょうか?

Hide code cell content
# assignメソッドで作品数-作者数を格納する列diffを一時的に作成し、それに基づき降順ソート
# headメソッドで上位5つを表示
df_cm.assign(diff=df_cm["マンガ作品数"] - df_cm["マンガ作者数"]).sort_values(
    by="diff", ascending=False
).head()
マンガ雑誌巻号名 マンガ作品数 マンガ作者数 合計ページ数 発売日 価格 マンガ雑誌名 diff
3933 週刊少年ジャンプ 2003年 表示号数37 40 22 463.0 2003-08-25 219.0 週刊少年ジャンプ 18
2126 週刊少年サンデー 2013年 表示号数6 49 33 482.0 2013-01-09 267.0 週刊少年サンデー 16
4224 週刊少年ジャンプ 2009年 表示号数37 32 27 535.0 2009-08-31 238.0 週刊少年ジャンプ 5
8997 週刊少年マガジン 2012年 表示号数50 33 29 472.0 2012-11-28 248.0 週刊少年マガジン 4
4565 週刊少年ジャンプ 2016年 表示号数44 25 22 497.0 2016-10-17 241.0 週刊少年ジャンプ 3

特に作者数に対して作品数が多かった、週刊少年ジャンプ 2003年 表示号数37(作品数:40、作者数:22)を詳しく見てみましょう:

Hide code cell content
# 週刊少年ジャンプ 2003年 表示号数37に掲載された各話を表示

# df_ceとdf_cc_crtをマージした一時的なDataFrameを作成
df_tmp = pd.merge(
    df_ce, df_cc_crt[["ccid", "crtid", "crtname"]], on="ccid", how="outer"
)

# df_ceデータフレームをフィルタリングして、
# '週刊少年ジャンプ 2003年 表示号数37'に掲載された作品の情報を抽出
# 必要なカラム(作品名、作者名、ページ数)のみを選択し、作者名で並べ替えて表示
df_tmp[df_tmp["miname"] == "週刊少年ジャンプ 2003年 表示号数37"][
    ["ccname", "crtname", "pages"]
].sort_values("crtname")
ccname crtname pages
69587 旅の達人 うすた京介 1.0
61449 ピューと吹く!ジャガー うすた京介 7.0
69314 神奈川磯南風天組 かずはじめ 19.0
69582 野津ケン割り!? かずはじめ 1.0
68872 ごっちゃんです!! つの丸 19.0
69591 つってつって つの丸 1.0
69595 ケイゴとタダノブ 久保帯人 1.0
59361 BLEACH 久保帯人 19.0
61747 HUNTER×HUNTER 冨樫義博 15.0
69585 夏休み 冨樫義博 1.0
69594 阿加木と千葉の夏休み 吉川雅之 1.0
69330 キックスメガミックス 吉川雅之 19.0
66462 武装錬金 和月伸宏 19.0
69592 嘘です 和月伸宏 1.0
69579 西の瓜割り 尾田栄一郎 1.0
54796 ONE PIECE 尾田栄一郎 20.0
56115 NARUTO-ナルト- 岸本斉史 35.0
69580 昆蟲採集 岸本斉史 1.0
63116 アイシールド21 村田雄介 19.0
69588 デビルバッツV.S.蚊 村田雄介 1.0
69586 スラッガー平塚 森田まさのり 1.0
69344 ROOKIES 森田まさのり 19.0
69590 きもだめし 武井宏之 1.0
68362 シャーマンキング 武井宏之 19.0
66238 いちご100% 河下水希 19.0
69584 胸騒ぎの夏休み 河下水希 1.0
65883 ボボボーボ・ボーボボ 澤井啓夫 15.0
69596 自由工作 澤井啓夫 1.0
69597 夜店 澤井啓夫 1.0
68652 BLACK CAT 矢吹健太朗 19.0
69593 アジトにて 矢吹健太郎 1.0
51536 こちら葛飾区亀有公園前派出所 秋本治 19.0
69581 両さんの夏休み 秋本治 1.0
69589 デビルバッツV.S.蚊 稲垣理一郎 1.0
63117 アイシールド21 稲垣理一郎 19.0
69577 恐怖のビーチ 許斐剛 1.0
64287 テニスの王子様 許斐剛 19.0
69578 すいか割り日記 鈴木信也 1.0
65649 Mr.FULLSWING 鈴木信也 15.0
68907 遊☆戯☆王 高橋和希 19.0
69583 砂浜の遊☆戯☆王 高橋和希 1.0
69576 ネコマジンみけ 鳥山明 19.0

雑誌巻号の企画として、ほぼすべての掲載作品が1ページの短編マンガを掲載していたようです。 同じく作者数に対して作品数が多い週刊少年サンデー 2013年 表示号数6(作品数:49、作者数:33)についても同様の結果が得られました。

なお、今回はマーカーの重複が多かったため、opacityオプションでマーカーを半透明にすることで密集度を表現しました。 他の対応策として、マーカーの位置にランダムにゆらぎ(ジッター[3]、ノイズ)を加える ジッタリング(Jittering) という方法もあります。

Hide code cell content
# 可視化用に新たにDataFrameを作成
df_cm2 = df_cm.copy()

# add_jitter関数でランダムなノイズ(scaleで標準偏差を指定可能)を追加
# seedをそれぞれ指定することで再現性を確保
df_cm2["マンガ作品数(ジッタリング後)"] = add_jitter(df_cm2["マンガ作品数"], scale=0.25, seed=0)
df_cm2["マンガ作者数(ジッタリング後)"] = add_jitter(df_cm2["マンガ作者数"], scale=0.25, seed=1)
Hide code cell content
# 可視化対象のDataFrameを確認
df_cm2.head()
マンガ雑誌巻号名 マンガ作品数 マンガ作者数 合計ページ数 発売日 価格 マンガ雑誌名 マンガ作品数(ジッタリング後) マンガ作者数(ジッタリング後)
0 週刊少年サンデー 1970年 表示号数32 12 14 284.0 1970-08-02 80.0 週刊少年サンデー 12.441013 14.406086
1 週刊少年サンデー 1970年 表示号数33 12 16 307.0 1970-08-09 90.0 週刊少年サンデー 12.100039 15.847061
2 週刊少年サンデー 1970年 表示号数34 13 17 314.0 1970-08-16 90.0 週刊少年サンデー 13.244684 16.867957
3 週刊少年サンデー 1970年 表示号数35 13 17 305.0 1970-08-23 90.0 週刊少年サンデー 13.560223 16.731758
4 週刊少年サンデー 1970年 表示号数36 13 17 305.0 1970-08-30 90.0 週刊少年サンデー 13.466889 17.216352
Hide code cell content
# 可視化対象DataFrameを保存
save_df_to_csv(df_cm2, DIR_OUT_CM, "cm2")
DataFrame is saved as '../../data/cm/output/08/scatter/cm2.csv'.
Hide code cell source
# px.scatter関数を使用して散布図を作成
# x軸に作品数(ジッタリング後)、y軸に作者数(ジッタリング後)をプロット
# 各点には雑誌巻号名、発売日、雑誌名、作品数、作者数をホバー情報として表示
fig = px.scatter(
    df_cm2,
    x="マンガ作品数(ジッタリング後)",
    y="マンガ作者数(ジッタリング後)",
    hover_name="マンガ雑誌巻号名",
    hover_data=["発売日", "マンガ雑誌名", "マンガ作品数", "マンガ作者数"],
)

# 散布図のマーカーのスタイルを更新
# サイズを10に設定し、線幅を1、透明度を0.5に設定
fig.update_traces(
    marker={
        "size": 10,
        "line_width": 1,
        "opacity": 0.5,
    }
)

# 散布図を表示
show_fig(fig)

上図は、マンガ雑誌巻号の作品数と作者数に対して、標準偏差\(0.25\)の正規分布[4]に従うノイズを加えた散布図です。 マーカーの重複が解消され、密集度がよりわかりやすくなりました。

ジッタリングはデータの分布を意図的に歪める手法です。 取り扱いには細心の注意を払いましょう。 まず、 ジッタリングを実施したという事実を必ず伝える ようにしましょう。 人が散布図を見るとき、暗黙のうちに各マーカーの座標がデータを直接表現していると仮定してしまうことがあります。 ジッタリングによってノイズが加えられているという前提を共有することで、認識の齟齬を回避することができます。 今回のように軸ラベルを利用しても良いですし、グラフ名やキャプションとして付け加えても良いでしょう。

次に、 ノイズのパラメータは慎重に選択 しましょう。 紙幅の都合から割愛しましたが、今回の標準偏差\(0.25\)を決定するまでに、筆者は複数のパターンを試しました。 標準偏差が小さすぎると効果がありませんし、逆に大きすぎると分布が変わりすぎてしまいます。 (ときにドメイン知識に基づき)試行錯誤を繰返し、ちょうどよいバランスの標準偏差を見つけましょう。

最後に、ノイズを 再現可能な設計 にしましょう。 (疑似)乱数等に基づいてジッタリングする際、その再現性が大きな課題となります。 極論を言うと、実行するたびに散布図の形状が変わってしまっては、科学的な議論は不可能です。 これに対応するため、本分析のadd_jitter関数は、同じseedを指定することで完全に同じノイズを再現可能な設計になっています。 散布図のジッタリングに限らず、データ分析において(疑似)乱数を利用する場合は、その再現性を強く意識しておきましょう。

マーカーの重複がひどく、散布図にこだわる必要がない場合は、二次元ヒストグラム等値線図などの他の手法を検討しても良いでしょう。

散布図の他の話題として、質的変数によるマーカーの色分けがあります。例えば、マンガ雑誌名に応じてマーカーを色分けすると、以下のようになります。

Hide code cell source
# px.scatter関数を使用して散布図を作成
# x軸に作品数(ジッタリング後)、y軸に作者数(ジッタリング後)をプロット
# マンガ雑誌名に応じてOKABE_ITOパレットで色分けし、シンボルも変更
# 各点には雑誌巻号名、発売日、雑誌名、作品数、作者数をホバー情報として表示
fig = px.scatter(
    df_cm2,
    x="マンガ作品数(ジッタリング後)",
    y="マンガ作者数(ジッタリング後)",
    color="マンガ雑誌名",
    symbol="マンガ雑誌名",
    hover_name="マンガ雑誌巻号名",
    hover_data=["発売日", "マンガ雑誌名", "マンガ作品数", "マンガ作者数"],
    color_discrete_sequence=OKABE_ITO,
)

# 散布図のマーカーのスタイルを更新
# サイズを10に設定し、線幅を1、透明度を0.5に設定
fig.update_traces(
    marker={
        "size": 10,
        "line_width": 1,
        "opacity": 0.5,
    }
)

# 散布図を表示
show_fig(fig)

上図は、マンガ雑誌別の雑誌巻号の作品数と作者数の関係を表した散布図です。マーカーの重複を避けるため、マンガ作品数およびマンガ作者数には標準偏差0.25の正規分布に従うノイズが付与さています。また、マーカーの配色とシンボルは、マンガ雑誌ごとに異なります。

ジッタリングを施してもなお、マンガ雑誌同士で激しく重複してしまっています。このような場合は、マンガ雑誌別にファセット(サブプロット)で分けると良いでしょう。 また、時代の変遷がわかりやすいよう、発売年を基準に色分けしてみましょう。

Hide code cell content
# 発売日をdatetime型に変換し、発売年情報を取得
df_cm2["発売年"] = pd.to_datetime(df_cm2["発売日"]).dt.year
Hide code cell source
# px.scatter関数を使用して散布図を作成
# x軸に作品数(ジッタリング後)、y軸に作者数(ジッタリング後)を指定
# マンガ雑誌名に応じてファセットを分け、ファセットは最大2列で折り返す
# 各点には雑誌巻号名、発売日、雑誌名、作品数、作者数をホバー情報として表示
# 複数の散布図を同時に表示するため、heightを調整
fig = px.scatter(
    df_cm2,
    x="マンガ作品数(ジッタリング後)",
    y="マンガ作者数(ジッタリング後)",
    color="発売年",
    facet_col="マンガ雑誌名",
    facet_col_wrap=2,
    hover_name="マンガ雑誌巻号名",
    hover_data=["発売日", "マンガ雑誌名", "マンガ作品数", "マンガ作者数"],
    height=600,
)

# 散布図のマーカーのスタイルを更新
# サイズを10に設定し、線幅を1、透明度を0.5に設定
fig.update_traces(
    marker={
        "size": 10,
        "line_width": 1,
        "opacity": 0.5,
    }
)

# ファセット(雑誌ごとの散布図)のタイトルを簡潔にする処理
# デフォルトではタイトルは「雑誌名=xxx」という形式になっている
# この処理は「=」で文字列を分割して「xxx」の部分だけを取り出す
fig.for_each_annotation(lambda a: a.update(text=a.text.split("=")[-1]))

# 散布図を表示
show_fig(fig)

上図は、マンガ雑誌巻号の発売年と作品数と作者数の関係を、マンガ雑誌別に散布図で表現したものです。 マーカーの重複を避けるため、マンガ作品数およびマンガ作者数には標準偏差0.25の正規分布に従うノイズが付与さています。

マーカーの色が暗いほど古い雑誌巻号、明るいほど新しい雑誌巻号を表しています。 全てのマンガ雑誌において徐々にマンガ作品やマンガ作者の数が増えていき、現在の形に収束したことがわかります。

四大少年誌の中で最も早い1959年3月17日[5]に、週刊少年サンデー週刊少年マガジンが創刊されました。 現在でこそほぼ全ての誌面がマンガで埋め尽くされていますが、創刊当初はどちらもマンガ以外のコンテンツが多く、本誌[6]の全ページに占めるマンガの割合は50%以下でした[修治, 2020]。 その後、時代の変遷とともにマンガ作品が占める割合が増えていき、最終的に現在の20本弱の体制となりました。 これは週刊少年ジャンプ[7]週刊少年チャンピオン[8]でも同様です。

アニメデータ#

アニメ作品に関する量的変数を可視化してみましょう。

Hide code cell content
# pandasのread_csv関数でCSVファイルの読み込み
df_ae = pd.read_csv(DIR_AN / FN_AE)
df_ac_act = pd.read_csv(DIR_AN / FN_AC_ACT)
Hide code cell content
# 可視化のための集計
# アニメ作品ごとの統計情報を集計して可視化用のデータフレームを作成

# 'acid'(アニメ作品ID)と'acname'(アニメ作品名)を基準にグルーピング
# 各アニメ作品ごとに'aeid'(各話ID)のユニーク数(各話数)と'date'(放送日)のユニーク数(放送日数)を計算
df_an = df_ae.groupby(["acid", "acname"])[["aeid", "date"]].nunique().reset_index()

# アニメ作品ごとに声優のユニーク数(声優数)を集計
# 'acid'(アニメ作品ID)を基準にグルーピングし、'actid'(声優ID)のユニーク数を計算
# その結果を辞書として保存し、アニメ作品IDをキーとして声優数を取得
acid2n_act = df_ac_act.groupby("acid")["actid"].nunique().to_dict()
df_an["n_act"] = df_an["acid"].apply(lambda x: acid2n_act.get(x, None))

# カラム名を変更して、結果をわかりやすくする
df_an = df_an.rename(
    columns={
        "acname": "アニメ作品名",
        "aeid": "アニメ各話数",
        "date": "放送日数",
        "n_act": "声優数",
    }
)
Hide code cell content
# 可視化対象のDataFrameを確認
df_an.head()
acid アニメ作品名 アニメ各話数 放送日数 声優数
0 C10001 ギャラクシー エンジェル 24 23 9.0
1 C10003 PROJECT ARMS 26 26 11.0
2 C10005 探偵少年カゲマン 6 5 12.0
3 C10006 Mr.Digital TOKORO the comical cartoon [第1期] 120 120 NaN
4 C10008 GEAR戦士[ギアファイター] 電童 38 38 10.0
Hide code cell content
# 可視化対象DataFrameを保存
save_df_to_csv(df_an, DIR_OUT_AN, "an")
DataFrame is saved as '../../data/an/output/08/scatter/an.csv'.
Hide code cell source
# アニメ作品の放送日数と各話数の関係を散布図で可視化
# '放送日数'をx軸に、'各話数'をy軸に取り、散布図を作成
# 'hover_name'引数に'アニメ作品名'を指定
fig = px.scatter(
    df_an,
    x="放送日数",
    y="アニメ各話数",
    hover_name="アニメ作品名",
)

# マーカーのスタイルを設定
# 'size'でマーカーのサイズを10に、'line_width'で境界線の幅を1に設定
# 'opacity'でマーカーの透明度を0.5に設定し、重複が多い部分の視認性を向上
fig.update_traces(
    marker={
        "size": 10,
        "line_width": 1,
        "opacity": 0.5,
    }
)

# 散布図を表示
show_fig(fig)

上図は、アニメ作品の合計放送日数と合計各話数の関係を表現した散布図です。 マーカーが右側に位置するほど合計放送日数が多く、上側に位置するほど合計各話数が多いアニメ作品を表します。

当然ですが、合計放送日数が増えれば増えるほど、合計各話数も増えていくことがわかりました。

基礎分析やこれまでの可視化から、一回の放送日に複数のアニメ各話を放送するアニメ作品が存在することがわかっています。 つまり、全体として直線\(y=x\)上、あるいはその左上の領域にマーカーが存在すると自然です。 マンガデータの例と同様、直線\(y=x\)を補助線として追加してこの仮説を確かめてみましょう。

Hide code cell source
# 直線y=xの最小値と最大値を規定
# 最小値は0、最大値は散布図の最大値の1.1倍
x_line = [0, df_an["放送日数"].max() * 1.1]

# 2点を使ってy = xの直線を追加
fig.add_trace(go.Scatter(x=x_line, y=x_line, mode="lines", name="y=x"))

# 散布図を表示
show_fig(fig)

赤い実線は、散布図の理解を助けるために導入した補助線\(y=x\)です。回帰直線ではありません のでご注意ください。

当初の想定通り、直線\(y=x\)より下の領域にアニメ作品はないように見えます。 念のため元データを確認してみましょう。

Hide code cell content
# 全てのアニメ作品について、各話数が放送日数以上であることを確認
assert all(df_an["放送日数"] <= df_an["アニメ各話数"])

全てのアニメ作品について、各話数が放送日数以上であることを確認できました。

では、本データ中には 再放送 は存在しないのでしょうか? あるいは、再放送は別のアニメ作品として格納されているのでしょうか? そうでなければ、全てのアニメ作品の放送日数が各話数より小さくなる理由が説明できません。

再放送は、日本のテレビアニメを語る上で欠かせません。 例えば、新世紀エヴァンゲリオンの再放送の人気をきっかけに、深夜アニメを中心とした第三次アニメブームが発生しました[信之, 2022]。 また、ルパン三世等、リアルタイムで視聴できなかった世代が再放送でその魅力に気づくということもよくあります。

まず、新世紀エヴァンゲリオンに該当するアニメ各話データを確認してみましょう。

Hide code cell content
# アニメ各話データにおいて、新世紀エヴァンゲリオンに部分一致するものを抽出
df_ae[df_ae["acname"].str.contains("新世紀エヴァンゲリオン")]
aeid aename date aeno acid acname asid
9469 M38296 第壱話 使徒、襲来 1995-10-04 第1話 C9249 新世紀エヴァンゲリオン C1790
9504 M38297 第弐話 見知らぬ、天井 1995-10-11 第2話 C9249 新世紀エヴァンゲリオン C1790
9546 M38298 第参話[縦書] 鳴らない、電 話 1995-10-18 第3話 C9249 新世紀エヴァンゲリオン C1790
9587 M38299 第四話 雨、逃げ出した後 1995-10-25 第4話 C9249 新世紀エヴァンゲリオン C1790
9629 M38300 第伍話 レイ、心のむこうに 1995-11-01 第5話 C9249 新世紀エヴァンゲリオン C1790
9667 M38301 第六話 決戦、第3新東京市 1995-11-08 第6話 C9249 新世紀エヴァンゲリオン C1790
9709 M38302 第七話 人の造りしもの 1995-11-15 第7話 C9249 新世紀エヴァンゲリオン C1790
9750 M38303 第八話 アスカ、来日 1995-11-22 第8話 C9249 新世紀エヴァンゲリオン C1790
9790 M38304 第九話 瞬間、心、重ねて 1995-11-29 第9話 C9249 新世紀エヴァンゲリオン C1790
9829 M38305 第拾話 マグマ ダイバー 1995-12-06 第10話 C9249 新世紀エヴァンゲリオン C1790
9871 M38306 第拾壱話 静止した闇の 中で 1995-12-13 第11話 C9249 新世紀エヴァンゲリオン C1790
9912 M38307 第拾弐話 奇跡の価値は 1995-12-20 第12話 C9249 新世紀エヴァンゲリオン C1790
9952 M38308 第拾参話 使徒、侵入 1995-12-27 第13話 C9249 新世紀エヴァンゲリオン C1790
9966 M38309 第拾四話 ゼーレ、魂の座 1996-01-03 第14話 C9249 新世紀エヴァンゲリオン C1790
9997 M38310 第拾五話[縦書] 嘘*と沈黙 1996-01-10 第15話 C9249 新世紀エヴァンゲリオン C1790
10036 M38311 第拾六話[縦書] 死に至る 病、そして 1996-01-17 第16話 C9249 新世紀エヴァンゲリオン C1790
10077 M38312 第拾七話 四人目 の適格者 1996-01-24 第17話 C9249 新世紀エヴァンゲリオン C1790
10117 M38313 第拾八話 命の 選択を 1996-01-31 第18話 C9249 新世紀エヴァンゲリオン C1790
10156 M38314 第拾九話 男の戰い 1996-02-07 第19話 C9249 新世紀エヴァンゲリオン C1790
10196 M38315 第弐拾話 心のかたち 人のかたち 1996-02-14 第20話 C9249 新世紀エヴァンゲリオン C1790
10234 M38316 第弐拾壱話 ネルフ、誕生 1996-02-21 第21話 C9249 新世紀エヴァンゲリオン C1790
10274 M38317 第弐拾弐話 せめて、人間らしく 1996-02-28 第22話 C9249 新世紀エヴァンゲリオン C1790
10314 M38318 第弐拾参話 涙 1996-03-06 第23話 C9249 新世紀エヴァンゲリオン C1790
10354 M38319 第弐拾四話 最後のシ者 1996-03-13 第24話 C9249 新世紀エヴァンゲリオン C1790
10390 M38320 第弐拾伍話 終わる世界 1996-03-20 第25話 C9249 新世紀エヴァンゲリオン C1790
10419 M38321 最終話 世界の中心で アイを叫んだ けもの 1996-03-27 第26話 C9249 新世紀エヴァンゲリオン C1790

少なくとも新世紀エヴァンゲリオンに関しては、再放送時のデータが存在しないことがわかりました。

では、特に各話数が放送日数より多いアニメ作品はどのようなものがあるでしょうか?

Hide code cell content
# assignメソッドで放送日数-各話数を格納する列diffを一時的に作成し、それに基づき降順ソート
# headメソッドで上位5つを表示
df_an.assign(diff=df_an["アニメ各話数"] - df_an["放送日数"]).sort_values(
    by="diff", ascending=False
).head()
acid アニメ作品名 アニメ各話数 放送日数 声優数 diff
3231 C8849 クレヨンしんちゃん 1926 994 17.0 932
161 C10364 あたしンち 668 333 17.0 335
2016 C14783 ふるさと 再生 日本の昔ばなし 564 243 1.0 321
3131 C7207 サザエさん 1175 855 10.0 320
2298 C15087 妖怪ウォッチ 374 151 NaN 223

クレヨンしんちゃんあたしンちふるさと 再生 日本の昔ばなし等、一放送枠に複数話を放送するアニメ作品が抽出されました。

そうだとすると、サザエさんの各話数が想定より少なく思えます。 サザエさんも基本的に一放送枠に複数話を放送する形式であるため、各話数が放送日数の二倍以上あった方が自然です。

Hide code cell content
# アニメ各話データから、アニメ作品名がサザエさんのデータを抽出
df_ae[df_ae["acname"] == "サザエさん"]
aeid aename date aeno acid acname asid
19266 M20986 もうすぐ ボーナス 作品No.4758(12/5) タラちゃん 走る 作品No.4759(1... 1999-12-05 1550 C7207 サザエさん C1945
19337 M20987 ワカメ ひみつの道草 作品No.4762(12/12) カツオ どこまで本当 作品No.47... 1999-12-12 1551 C7207 サザエさん C1945
19406 M20988 イクラの おばあ ちゃん 作品No.4764(12/19) わが家の リサイクル 作品No.... 1999-12-19 1552 C7207 サザエさん C1945
19470 M20989 ぼくたち 歳末障子組 作品No.4766(12/26) 今年最後の運だめし 作品No.476... 1999-12-26 1553 C7207 サザエさん C1945
19471 M20990 サザエさん一家の 珍諸国漫遊記(12/26) 75点の 天才!(再) 1999-12-26 S1 C7207 サザエさん C1945
... ... ... ... ... ... ... ...
108216 M21375 秘めたる 愛 情 作品No.7553 2016-12-18 2387B C7207 サザエさん C1945
108217 M21376 サラリーマンの 休 日 作品No.7555 2016-12-18 2387C C7207 サザエさん C1945
108346 M21377 ハチとタラちゃん 作品No.7556 2016-12-25 2388A C7207 サザエさん C1945
108347 M21378 暮れの 大仕事 作品No.7539 2016-12-25 2388B C7207 サザエさん C1945
108348 M21379 おモチ 大好き 作品No.7208 2016-12-25 2388C C7207 サザエさん C1945

1175 rows × 7 columns

一放送枠の 全ての各話を一つに集約する 行と、それぞれ 別の各話として扱う 行が混在している[9]ことがわかりました。 紙幅の都合のためこれ以上の作業は割愛しますが、本格的なデータ分析に進む前に、アニメ各話の定義を統一するための前処理が必要になるでしょう。

ゲームデータ#

ゲームプラットフォームに関する量的変数を可視化してみましょう。

Hide code cell content
# pandasのread_csv関数でCSVファイルの読み込み
df_pkg_pf = pd.read_csv(DIR_GM / FN_PKG_PF)
Hide code cell content
# 可視化のための集計
# プラットフォームごとのパッケージ数、パブリッシャー数、平均価格を算出

# 'pfname'(プラットフォーム名)ごとに'pkgid'(パッケージID)、'publisher'(パブリッシャー)、
# そして'price'(価格)を集計
# 'pkgid'はユニークな値の数(パッケージ数)、'publisher'はユニークな値の数(パブリッシャー数)を計算
# 'price'は平均価格を計算
df_gm = (
    df_pkg_pf.groupby("pfname")[["pkgid", "publisher", "price"]]
    .agg({"pkgid": "nunique", "publisher": "nunique", "price": "mean"})
    .reset_index()
)

# カラム名をわかりやすい名前に変更
df_gm = df_gm.rename(
    columns={
        "pfname": "プラットフォーム名",
        "pkgid": "ゲームパッケージ数",
        "publisher": "パブリッシャー数",
        "price": "平均価格",
    }
)
Hide code cell content
# 可視化対象のDataFrameを確認
df_gm.head()
プラットフォーム名 ゲームパッケージ数 パブリッシャー数 平均価格
0 3DO 115 68 8261.913043
1 64DD 2 1 NaN
2 ClassicMacOS 13 3 615.384615
3 MSX 1 1 5800.000000
4 MSX2 4 4 8800.000000
Hide code cell content
# 可視化対象DataFrameを保存
save_df_to_csv(df_gm, DIR_OUT_GM, "gm")
DataFrame is saved as '../../data/gm/output/08/scatter/gm.csv'.
Hide code cell source
# プラットフォームごとのパッケージ数とパブリッシャー数の関係を散布図で可視化
# 'df_gm'データフレームを用いて、x軸に'パッケージ数'、y軸に'パブリッシャー数'をプロット
# 各点はプラットフォームを表し、ホバーすると'プラットフォーム名'が表示される
fig = px.scatter(
    df_gm,
    x="ゲームパッケージ数",
    y="パブリッシャー数",
    hover_name="プラットフォーム名",
)

# 散布図のマーカーのスタイルを設定
# サイズは10、線の幅は1、透明度は0.5(重複が多いため)
fig.update_traces(
    marker={
        "size": 10,
        "line_width": 1,
        "opacity": 0.5,
    }
)

# 散布図を表示
show_fig(fig)

上図は、ゲームプラットフォームごとの合計パッケージ数と合計パブリッシャー数の関係を表現した散布図です。 図中右側に位置するほどパッケージ数が多く、上部に位置するほどパブリッシャー数が多いゲームプラットフォームを表します。 最もゲームパッケージ数が多いのはプレイステーション2であり、最もパブリッシャー数が多いのはプレイステーションです。

では、メーカーごとに特徴はあるのでしょうか?

Hide code cell content
# PF2MKに格納されているプラットフォームのみ抽出
df_gm2 = df_gm[df_gm["プラットフォーム名"].isin(PF2MK.keys())].reset_index(drop=True)
# PF2MKを用いて、プラットフォーム名からメーカー名をマッピング
df_gm2["メーカー名"] = df_gm2["プラットフォーム名"].map(PF2MK)
Hide code cell content
# 可視化対象のDataFrameを確認
df_gm2.head()
プラットフォーム名 ゲームパッケージ数 パブリッシャー数 平均価格 メーカー名
0 NINTENDO64 160 51 7054.012500 任天堂
1 NintendoSwitch 320 97 3370.728435 任天堂
2 SC-3000 2 1 4300.000000 セガ
3 SEGAマーク3 9 1 4944.444444 セガ
4 SG-1000 2 1 4300.000000 セガ
Hide code cell content
# 可視化対象DataFrameを保存
save_df_to_csv(df_gm2, DIR_OUT_GM, "gm2")
DataFrame is saved as '../../data/gm/output/08/scatter/gm2.csv'.
Hide code cell source
# プラットフォームごとのパッケージ数とパブリッシャー数の関係を散布図で可視化
# 'df_gm2'データフレームを用いて、x軸に'パッケージ数'、y軸に'パブリッシャー数'を指定
# 'メーカー名'に応じてKAKBE_ITOテーマで配色し、シンボルも変更
# 各点はプラットフォームを表し、ホバーすると'プラットフォーム名'が表示される
fig = px.scatter(
    df_gm2,
    x="ゲームパッケージ数",
    y="パブリッシャー数",
    color="メーカー名",
    symbol="メーカー名",
    hover_name="プラットフォーム名",
    color_discrete_sequence=OKABE_ITO,
)

# 散布図のマーカーのスタイルを設定
# サイズは10、線の幅は1、透明度は0.5(重複が多いため)
fig.update_traces(
    marker={
        "size": 10,
        "line_width": 1,
        "opacity": 0.5,
    }
)

# 散布図を表示
show_fig(fig)

上図は、ゲームプラットフォームごとのパッケージ数とパブリッシャー数の関係を表現した散布図です。メーカー名に応じてマーカーの配色とシンボルを変えてあります。

ソニー[1]は比較的パッケージ数もパブリッシャー数も多く、セガはその逆、任天堂は両者の中間程度ということがわかりました。